Skip to content

feat(validation): P3-3 OOS stability validation (equal_weight vs ic_weighted) - #12

Merged
StackOverFlow11 merged 5 commits into
mainfrom
p3-oos-stability-validation
Jun 10, 2026
Merged

feat(validation): P3-3 OOS stability validation (equal_weight vs ic_weighted)#12
StackOverFlow11 merged 5 commits into
mainfrom
p3-oos-stability-validation

Conversation

@StackOverFlow11

@StackOverFlow11 StackOverFlow11 commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

P3-3 — OOS stability validation. Before adding any further alpha sophistication, this PR validates whether the P3-1 equal_weight and P3-2 ic_weighted combinations are stable out of sample. It is a report-only validation layer: no new alpha complexity, no change to portfolio / execution / factor math.

Implementation

  • Runner qt/oos_stability.py + CLI run-phase3-oos: ONE shared data load and ONE processed factor panel; the backtest runs twice (EqualWeightAlpha / RollingICWeightAlpha) under identical universe / neutralization / portfolio / execution rules.
  • Split: oos.split_date (new optional config section, cross-validated to lie strictly inside the data window) — train [start, split), test [split, end]; subperiod navs are rebased so no drawdown/annualization bleeds across the boundary.
  • Config config/phase3_real_oos_stability.yaml: SSE50 extended to TWO years (2022-07 ~ 2024-06), split 2023-07-01 → train 1y / test 1y. The test year equals the phase3 baselines' window so numbers line up.

Lookahead boundary

Evaluation is walk-forward (rolling subperiod): weights at any date d use only observations realized by d (t + h <= d, the P3-2 property). The new split-boundary no-leakage test perturbs EVERY post-split forward return and asserts all train-period weights are bit-identical — no test-period return can reach a train-period computation. Freeze-at-split was deliberately NOT added (a new alpha mode = new alpha complexity, explicitly out of scope).

Real run (77 names, 2 years, ~16 min) — the honest headline is INSTABILITY

Subperiod performance (same data, same rules):

Model train (22-07→23-06) test (23-07→24-06)
equal_weight −11.92% (sharpe −0.59) −5.27% (sharpe −0.33)
ic_weighted −8.31% (sharpe −0.33) −2.70% (sharpe −0.16)

(11 + 11 rebalances; the 2023-06-30 rebalance — whose holding window straddles the split — is excluded from both subperiods and disclosed in the report.)

IC stability — the key finding:

Series train IC test IC sign consistent hit rate
momentum_20 −0.0238 +0.0062 NO 48% / 53%
roe −0.0286 +0.0066 NO 46% / 51%
netprofit_yoy −0.0103 +0.0046 NO 49% / 52%
combo_ic_weighted +0.0022 +0.0040 YES (but ≈0) 49% / 52%

Weight stability: sign flips across 23 rebalances — momentum_20 ×7, roe ×3, netprofit_yoy ×4; 20 equal-weight fallbacks (all "insufficient realized IC history", before the 60d window filled).

Conclusion (disclosed in the report): all three raw factors FLIP IC sign train→test with coin-flip hit rates; ic_weighted edges equal_weight in both subperiods but with near-zero, sign-flipping ICs this must NOT be read as skill — the P3-2 one-year result does not extrapolate. Surfacing exactly this was the purpose of the validation layer. NOT a return claim.

Regressions & secret scan

Check Result
phase3 equal_weight real rerun −9.05% / IC 0.0083 unchanged
phase3 ic_weighted real rerun −3.57% unchanged
demo run-phase0 ic 0.96 / annual 0.84 unchanged
secret scan report + diff contain no token / .config.json content

Quality gates

Gate Result
pytest -p no:cacheprovider 282 passed (was 269: +13 OOS)
ruff check . clean
validate-config ×6 (all old + phase3_real_oos_stability) OK
Hygiene no artifacts/parquet tracked, no conflict markers

Tests (network-free)

test_oos_stability.py (13): split-boundary no-leakage (perturb all post-split returns → train weights bit-identical), strict subperiod slicing + rebased nav + empty-slice NaN, IC period stats (mean/IR/hit-rate/n), nonzero-same-sign consistency, weight sign flips on trained rows only (fallback rows excluded), fallback-reason aggregation, OOS config validation + split-inside-window ConfigError, demo-source + missing-oos guards, report renders boundaries/metrics/stability/caveat with no secret.

Docs

RUNBOOK (P3-3 section), TEST_REPORT (282 + breakdown), CLAUDE.md / AGENTS.md progress (incl. the instability finding).

Review fixes (0035a04)

Two HIGH findings, both fixed + real rerun:

  • HIGH — holding-window slicing: subperiod performance had been sliced by the nav row's signal date, crediting the straddling 2023-06-30 rebalance's July (test-period) return to train. Now split_nav_by_holding assigns train ⇔ holding END ≤ split, test ⇔ holding START ≥ split; straddlers (and the unknown-end last row) are excluded from both and disclosed; IC stats slice by the realization date (t+horizon). Real impact: train eq −6.81%→−11.92%, ic −1.69%→−8.31% (the correction magnitude is itself evidence of the boundary bug); test unchanged; the IC sign-flip finding unchanged (all NO).
  • HIGH — fake-comparison guard: run-phase3-oos now refuses alpha.model != 'ic_weighted' (it would silently run equal_weight twice and label one leg ic_weighted). Readable ValueError + network-free test.

Gates after fixes: 285 passed / ruff clean / OOS real rerun 929s with the corrected report.

  • LOW fix (f90c36d): the weights table now labels a straddling rebalance row boundary (was train), matching the performance slicing. Display-only; locked by a render test.

…eighted)

A REPORT-ONLY validation layer -- no new alpha complexity, no change to
portfolio / execution / factor math. Validates whether the P3-1 equal-weight
and P3-2 ic-weighted combinations are stable out of sample before adding any
further alpha sophistication.

Runner (qt/oos_stability.py, CLI run-phase3-oos):
- ONE shared data load + ONE processed factor panel; the backtest runs twice
  (EqualWeightAlpha / RollingICWeightAlpha) under identical universe,
  neutralization, portfolio and execution rules.
- Every diagnostic is split at oos.split_date: train = [start, split),
  test = [split, end]; subperiod navs are rebased so nothing bleeds across.
- Split semantics are walk-forward (rolling subperiod): weights at any date
  use only observations realized by that date (t + h <= d). A split-boundary
  test proves perturbing EVERY post-split forward return leaves all
  train-period weights bit-identical. Freeze-at-split is deliberately NOT
  added (it would be a new alpha mode, out of scope).
- Guards: requires data.source=tushare and an 'oos' config section; the
  split date must lie strictly inside the data window (config-validated).

Report (phase3_oos_stability.md): split boundaries (exact realized dates +
day counts), per-subperiod performance for both models (annual / vol /
sharpe / maxDD / turnover / rebalances), per-series IC stability (mean / IR /
hit rate / n + train-vs-test sign consistency) for every raw factor and both
combo scores, ic-weighted weight stability (per-rebalance weights labelled
train/test, sign flips on trained rows, fallback count + reasons), and the
explicit small-sample / not-a-return-claim caveat.

Real run (SSE50, 2y 2022-07~2024-06, split 2023-07-01, 77 names, ~16 min) --
the honest headline is INSTABILITY:
- all three raw factors FLIP IC sign train->test (momentum_20 -0.024->+0.006,
  roe -0.029->+0.007, netprofit_yoy -0.010->+0.005; sign consistency NO);
  hit rates 46-53% (coin-flip range);
- ic_weighted weights flip sign 7/3/4 times across 23 rebalances;
- performance: equal_weight train -6.81% / test -5.27%; ic_weighted train
  -1.69% / test -2.70%. ic_weighted edges equal_weight in BOTH subperiods,
  but with near-zero, sign-flipping ICs this must NOT be read as skill --
  the P3-2 one-year result does not extrapolate, which is exactly what this
  validation layer was built to surface.

Regressions: phase3 equal_weight rerun -9.05% / IC 0.0083 and ic_weighted
rerun -3.57% both unchanged; demo unchanged (ic 0.96 / annual 0.84); the
report contains no token / secret-file content (scanned).

Tests: 282 passed (was 269): +13 OOS (split no-leakage, subperiod slicing,
IC period stats, sign consistency, weight sign flips excl. fallback rows,
fallback-reason aggregation, config + guard + render contracts).
Review HIGH fixes on PR #12:

1. Subperiod performance was sliced by the nav row's SIGNAL date, but a row's
   return covers the holding window [rebalance, next rebalance] -- with
   split=2023-07-01 the 2023-06-30 row (held into July) credited test-period
   return to train. Now split_nav_by_holding assigns train <=> holding END
   on/before the split, test <=> holding START on/after it; straddling rows
   (incl. the last row, whose end is the unknown terminal candidate) are
   EXCLUDED from both and disclosed in the report. IC stats are likewise
   sliced by the realization date (t + horizon), not the factor date alone.
   Real impact (rerun, 929s): train eq -6.81% -> -11.92%, ic -1.69% -> -8.31%
   (the old train was polluted by post-split July returns); test unchanged
   (-5.27% / -2.70%); IC sign-flip finding unchanged (all NO).
2. run-phase3-oos now REQUIRES alpha.model='ic_weighted': any other model
   would silently run equal_weight twice and label one leg ic_weighted (a
   fake comparison). Readable ValueError + network-free test.

Tests: 285 passed (was 282): +2 holding-window slicing (straddler + unknown
-end), +1 fake-comparison guard; ic_period_stats test rewritten for
realization-date slicing; render test asserts the straddler disclosure.
Docs (RUNBOOK/TEST_REPORT/CLAUDE/AGENTS) updated with the new semantics and
corrected real numbers.
…able

Review LOW on PR #12: the per-rebalance weights table labelled the straddling
2023-06-30 row 'train' while the performance slicing had already excluded it
from both subperiods as a boundary row. The period label in the weights table
now matches the performance slicing (boundary, not train/test). Display-only;
no metric changes. Render test asserts the boundary label.
…er review

Doc-only sync in CLAUDE.md + AGENTS.md (both files, same drift):
- merged-PR lists: add #10 (progress docs) and #11 (P3-2).
- P3-2 entry: 'branch, awaiting acceptance' -> 'PR #11 merged to main'.
- P3-3 entry: 'branch, awaiting acceptance' -> 'PR #12 OPEN, review HIGHs+LOW
  fixed, awaiting acceptance/merge'.
Maintaining two slightly-divergent progress docs invited drift (three review
findings so far were exactly this). AGENTS.md is now a straight copy of
CLAUDE.md; future updates edit CLAUDE.md and copy over.
@StackOverFlow11
StackOverFlow11 merged commit 4a74da8 into main Jun 10, 2026
StackOverFlow11 added a commit that referenced this pull request Jun 10, 2026
docs: mark PR #12 (P3-3) merged in progress docs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant